Skip to content

feat(ui): enrich desktop notifications (task 19)#113

Merged
mpiton merged 4 commits intomainfrom
feat/task-19-notifications-enriched
Apr 27, 2026
Merged

feat(ui): enrich desktop notifications (task 19)#113
mpiton merged 4 commits intomainfrom
feat/task-19-notifications-enriched

Conversation

@mpiton
Copy link
Copy Markdown
Owner

@mpiton mpiton commented Apr 27, 2026

Summary

• Rich notification body with filename, size, speed, duration (e.g., video.mp4 · 512.3 MB · 2.5 MB/s · 3m 25s)
• Burst aggregation: ≥3 simultaneous notifications collapse to single summary within 5s window
• Domain layer: pure formatting + sliding-window state machine (std-only, zero external deps)
• Respect notifications_enabled config gate (immediate toggle)
• Error notification includes truncated error message (capped 200 chars to prevent toast overflow)

Implementation

Domain (src-tauri/src/domain/notification/):

  • format.rs: Three pure helpers (format_size, format_speed, format_duration) with 13 unit tests
  • grouper.rs: NotificationGrouper state machine with 10 tests (single/burst/window-drain/boundary scenarios)

Bridge (src-tauri/src/adapters/driven/notification/notification_bridge.rs):

  • DownloadCompleted: format enriched body; check grouper decision; emit aggregate or single
  • DownloadFailed: include error (truncated); always show immediately (no aggregation)
  • Config gate: is_notifications_enabled() called per event for immediate Setting respect
  • 8 integration tests covering edge cases (missing detail, speed/duration omission, error truncation)

Wiring (lib.rs):

  • Pass Arc + Arc to spawn_notification_bridge
  • Clone Arc for multiple ownership in CommandBus/QueryBus + bridge subscription

Notes

  • Upstream limitation: tauri-plugin-notification 2.3.3 doesn't expose click callback; click-to-open blocked pending plugin upgrade or direct notify_rust dependency
  • All tests passing: cargo test (999 tests), clippy strict, oxlint, vitest frontend (582 tests)
  • Task 19/82 Phase 0: ✅ body format, ✅ grouping, ✅ setting gate; ⏸️ click action (upstream)

Summary by cubic

Enriched desktop download notifications with filename + size, burst grouping, and instant respect of the notifications_enabled toggle via tauri-plugin-notification. Implements Task 19; click-to-open is deferred due to tauri-plugin-notification limits.

  • New Features

    • DownloadCompleted: body shows "{filename} · {size}" (speed/duration omitted for now).
    • Burst aggregation: 5s window; the 3rd completion shows "N downloads completed"; others in-window are suppressed.
    • DownloadFailed: includes filename and a truncated error (200 chars).
    • Domain helpers (format_size, format_speed, format_duration) and NotificationGrouper (std-only); bridge wired with shared Arc<dyn ConfigStore> and Arc<dyn DownloadReadRepository>; reads notifications_enabled on every event.
  • Bug Fixes

    • Size formatter promotes unit on rounding (avoids "1024.0 KB"; e.g., 1 MiB − 1 B → "1.0 MB").
    • Grouper resets on backward clock jumps; error truncation now caps at exactly 200 chars (including ellipsis).
    • Always logs DownloadFailed before the notifications gate and removes raw error text from global logs to avoid leaking credentials (full error remains in per-download logs).

Written for commit 86355f2. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Desktop notifications now re-check user preference per event, aggregate rapid completions, and show filename + size; failures include filename (or id) with truncated error text.
    • System tray displays an animated orange-dot pulse while downloads are active.
    • Download engine adds dynamic segment splitting with live config updates and improved crash-consistent resume.
    • Plugins: “Report broken plugin” flow with validated issue URL generation and localized strings.

Wire the tauri-plugin-notification bridge to honour the user's
`notifications_enabled` flag on every event, surface filename + size +
avg speed + duration on `DownloadCompleted`, and prefix the failure
reason with the filename on `DownloadFailed` (truncated to 200 chars
to cap the toast and avoid leaking long URL/credential payloads).

Bursts of completions are debounced through a new pure-domain
`NotificationGrouper` (5 s sliding window, threshold 3): the third
event in the window collapses into a single "N downloads completed"
toast and any further completions in the same window are suppressed.

Pure helpers `format_size` / `format_speed` / `format_duration` live
under `domain/notification/format.rs` (base 1024, one decimal,
zero-leading components dropped) so the formatting policy is
testable without an adapter and the domain layer keeps its std-only
discipline (no `humansize` dependency).

The bridge call site in `lib.rs` now threads the existing
`Arc<dyn ConfigStore>` and `Arc<dyn DownloadReadRepository>` so the
gating + lookup share the instances the IPC layer already mutates,
no double-instantiation.

Click-to-open / click-to-focus actions are blocked upstream:
`tauri-plugin-notification` 2.3.3 desktop API consumes the
`NotificationHandle` returned by `notify_rust` internally so the
click callback is unreachable. Documented in `notification_bridge.rs`
and tracked for revisit when the plugin exposes `on_event`.
@github-actions github-actions Bot added documentation Improvements or additions to documentation rust labels Apr 27, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

Desktop notifications now re-check persisted notifications_enabled per event, use a time-windowed NotificationGrouper to aggregate or suppress completion toasts, enrich single notifications with filename and formatted size via DownloadReadRepository, truncate failure reasons, centralize send logic, add pure formatting helpers, and update startup wiring to pass ConfigStore and DownloadReadRepository into the bridge.

Changes

Cohort / File(s) Summary
Notification Domain Utilities
src-tauri/src/domain/notification/format.rs, src-tauri/src/domain/notification/grouper.rs, src-tauri/src/domain/notification/mod.rs, src-tauri/src/domain/mod.rs
Added pure formatting helpers (format_size, format_speed, format_duration) and a time-windowed NotificationGrouper with constants (GROUPING_WINDOW_SECS, GROUPING_THRESHOLD) plus unit tests.
Notification Bridge Adapter
src-tauri/src/adapters/driven/notification/notification_bridge.rs
Refactored spawn_notification_bridge signature to accept Arc<dyn ConfigStore> and Arc<dyn DownloadReadRepository>; reads notifications_enabled per event, uses a mutex-protected NotificationGrouper to ShowSingle/ShowAggregated/Suppress, enriches single notifications via DownloadDetailView (filename + formatted size), truncates failure messages, centralizes send logic, and adds tests for formatting/aggregation/truncation.
Startup Wiring & Changelog
src-tauri/src/lib.rs, CHANGELOG.md
Updated run() wiring to clone and pass config_store and download_read_repo into spawn_notification_bridge. CHANGELOG updated with related notification entries.

Sequence Diagram(s)

sequenceDiagram
    participant EventBus
    participant NotificationBridge
    participant ConfigStore
    participant DownloadReadRepository
    participant NotificationGrouper
    participant DesktopNotification as Desktop Notification

    EventBus->>NotificationBridge: DomainEvent (DownloadCompleted / DownloadFailed)
    NotificationBridge->>ConfigStore: read notifications_enabled
    alt notifications_enabled == true
        NotificationBridge->>NotificationGrouper: record(now_secs)
        NotificationGrouper-->>NotificationBridge: Decision (ShowSingle / ShowAggregated / Suppress)
        alt ShowSingle
            NotificationBridge->>DownloadReadRepository: find_download_detail(id)
            DownloadReadRepository-->>NotificationBridge: filename, size
            NotificationBridge->>NotificationBridge: format_size / format_speed / format_duration
            NotificationBridge->>DesktopNotification: show enriched notification
        else ShowAggregated
            NotificationBridge->>DesktopNotification: show "N downloads completed"
        else Suppress
            Note over NotificationBridge: suppress notification
        end
    else notifications_enabled == false
        Note over NotificationBridge: skip notification
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~40 minutes

Possibly related PRs

Poem

🐰 I nudged the bytes, then gave a cheer,
Names and sizes crisp and clear,
Grouped the finishes, hushed the flood,
A pulsing dot, a tidy thud,
I hop — notifications are dear.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat(ui): enrich desktop notifications (task 19)' directly aligns with the main change: adding enriched notification bodies with filename, size, speed, duration, and implementing burst aggregation—core features of desktop notification enhancement.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/task-19-notifications-enriched

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 27, 2026

Greptile Summary

Implements task 19 enriched desktop notifications: the bridge now reads notifications_enabled per-event, enriches DownloadCompleted bodies with filename + size via a read-repository lookup, truncates DownloadFailed error text to 200 chars, and debounces bursts through a new pure-domain NotificationGrouper (5 s sliding window, threshold 3). Speed/duration are deliberately omitted pending a transfer_started_at metric in the read model, and tracing::error! for failures is correctly moved before the notification gate.

Confidence Score: 4/5

Safe to merge with awareness of two open discussions from the previous review round that remain unresolved in code

No new P0 or P1 issues found in this pass. The previous review flagged the IEC/SI unit label mismatch (KB/MB/GB/TB with base-1024 arithmetic) and the ShowAggregated UX interaction (events 1 and 2 show individual toasts before event 3 fires an aggregated one) — both are still present but appear to be deliberate trade-offs acknowledged in the PR description and CHANGELOG. Score capped at 4 to reflect the open P1-level prior threads.

src-tauri/src/domain/notification/format.rs (SI/IEC label choice), src-tauri/src/adapters/driven/notification/notification_bridge.rs (burst aggregation UX)

Important Files Changed

Filename Overview
src-tauri/src/adapters/driven/notification/notification_bridge.rs Major rewrite: adds config gate, burst grouper, enriched bodies (filename+size), error truncation, and error logging before the notification gate; previous P1s (ShowAggregated with individual toasts, IEC labels) remain open
src-tauri/src/domain/notification/grouper.rs New pure domain state machine for burst aggregation; 5 s sliding window + threshold 3 with backward-clock reset; 10 tests covering all boundary scenarios
src-tauri/src/domain/notification/format.rs New pure formatting helpers; uses base-1024 arithmetic with SI unit labels (KB/MB/GB/TB instead of KiB/MiB/GiB/TiB) — label/arithmetic mismatch flagged in previous review and still present
src-tauri/src/domain/notification/mod.rs New module entry point re-exporting format helpers and grouper types; clean std-only domain layer
src-tauri/src/lib.rs Threads Arc and Arc to spawn_notification_bridge via .clone(); minimal, correct wiring change
CHANGELOG.md Detailed changelog entry for task 19 enriched notifications; acknowledges speed/duration omission and click-action deferral

Sequence Diagram

sequenceDiagram
    participant EB as EventBus
    participant NB as notification_bridge
    participant CS as ConfigStore
    participant RR as DownloadReadRepository
    participant NG as NotificationGrouper
    participant OS as OS Toast

    EB->>NB: DomainEvent::DownloadFailed
    NB->>NB: tracing::error! (before gate)
    NB->>CS: is_notifications_enabled()
    alt notifications disabled
        NB-->>NB: return (toast suppressed)
    else notifications enabled
        NB->>RR: find_download_detail(id)
        NB->>OS: "Download failed · filename · Error: ..."
    end

    EB->>NB: DomainEvent::DownloadCompleted
    NB->>CS: is_notifications_enabled()
    alt notifications disabled
        NB-->>NB: return
    else notifications enabled
        NB->>NG: record(epoch_secs)
        alt ShowSingle
            NB->>RR: find_download_detail(id)
            NB->>OS: "filename · size"
        else ShowAggregated{count}
            NB->>OS: "N downloads completed"
        else Suppress
            NB-->>NB: no-op
        end
    end
Loading

Reviews (4): Last reviewed commit: "fix(notification): drop raw error text f..." | Re-trigger Greptile

Comment thread src-tauri/src/adapters/driven/notification/notification_bridge.rs
Comment thread src-tauri/src/adapters/driven/notification/notification_bridge.rs
Comment thread src-tauri/src/domain/notification/format.rs
Comment thread src-tauri/src/adapters/driven/notification/notification_bridge.rs
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src-tauri/src/adapters/driven/notification/notification_bridge.rs`:
- Around line 129-134: The code incorrectly derives transfer statistics from
created_at (variables: created_at, now_secs, duration) which inflates durations
for queued/paused downloads; remove or guard the avg_speed and duration
formatting (the lines that push format_speed(total / duration.max(1)) and
format_duration(duration) into parts) and instead only show those fields when a
real active-transfer metric is available (e.g., a transfer_start or
active_duration field on d) or omit them entirely until the read model exposes
them; update the logic around d.total_bytes and parts.push(...) in
notification_bridge.rs to check for an explicit transfer-start/active-duration
metric or skip adding speed/duration.
- Around line 151-157: The truncate_error function currently takes
MAX_ERROR_BODY_CHARS characters then appends the ellipsis, producing a string
one char longer than the stated cap; change the logic in truncate_error to
return the original string when error.chars().count() <= MAX_ERROR_BODY_CHARS,
otherwise collect only MAX_ERROR_BODY_CHARS - 1 characters and then append '…'
so the final length is at most MAX_ERROR_BODY_CHARS (also guard against the
edge-case where MAX_ERROR_BODY_CHARS is 0 or 1 by handling those sizes
explicitly before subtracting).

In `@src-tauri/src/domain/notification/format.rs`:
- Around line 17-23: The formatting chooses the unit before rounding, so values
like 1024*1024-1 can display as "1024.0 KB"; adjust the logic in the function(s)
(e.g., format_bytes and format_speed) to perform rounding-aware unit selection:
after computing value and idx as shown, round the value to one decimal (or
compute the rounded_value that would be displayed) and if the rounded_value >=
1024.0 then divide value by 1024.0 and increment idx (ensuring idx stays within
UNITS bounds) before formatting; this ensures boundary cases roll over to the
next unit correctly.

In `@src-tauri/src/domain/notification/grouper.rs`:
- Around line 75-81: Detect and handle wall-clock backwards jumps in record():
if the incoming now_secs is less than the deque's back (most recent) timestamp,
clear or reset the timestamps deque and the burst_aggregated flag before pushing
the new time so stale "future" entries can't corrupt the 5-second window; update
the same logic in the sibling method that mirrors 99-107 (the other notification
recording/aggregation path) to perform the same check (compare now_secs with
timestamps.back(), clear timestamps and reset burst_aggregated when now_secs <
back) and then proceed to call prune(now_secs) and push_back(now_secs).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6a406736-917f-4a7d-951a-7c572ce12eae

📥 Commits

Reviewing files that changed from the base of the PR and between 33e0050 and cdca914.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • src-tauri/src/adapters/driven/notification/notification_bridge.rs
  • src-tauri/src/domain/mod.rs
  • src-tauri/src/domain/notification/format.rs
  • src-tauri/src/domain/notification/grouper.rs
  • src-tauri/src/domain/notification/mod.rs
  • src-tauri/src/lib.rs

Comment thread src-tauri/src/adapters/driven/notification/notification_bridge.rs Outdated
Comment thread src-tauri/src/adapters/driven/notification/notification_bridge.rs
Comment thread src-tauri/src/domain/notification/format.rs
Comment on lines +75 to +81
pub fn record(&mut self, now_secs: u64) -> NotificationDecision {
self.prune(now_secs);
// Window completely drained → previous burst ends, reset flag.
if self.timestamps.is_empty() {
self.burst_aggregated = false;
}
self.timestamps.push_back(now_secs);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reset burst state when wall-clock time moves backward.

record() assumes timestamps stays sorted, but the bridge feeds it wall-clock seconds. If the system clock jumps backward, older “future” entries remain in the deque and later completions can be aggregated/suppressed against the wrong 5-second window.

💡 Suggested fix
 pub fn record(&mut self, now_secs: u64) -> NotificationDecision {
+    if self.timestamps.back().is_some_and(|&last| now_secs < last) {
+        self.timestamps.clear();
+        self.burst_aggregated = false;
+    }
     self.prune(now_secs);
     // Window completely drained → previous burst ends, reset flag.
     if self.timestamps.is_empty() {
         self.burst_aggregated = false;
     }
+    #[test]
+    fn test_backward_clock_resets_burst_state() {
+        let mut g = NotificationGrouper::new();
+        g.record(100);
+        g.record(101);
+        assert_eq!(g.record(99), NotificationDecision::ShowSingle);
+    }

Also applies to: 99-107

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src-tauri/src/domain/notification/grouper.rs` around lines 75 - 81, Detect
and handle wall-clock backwards jumps in record(): if the incoming now_secs is
less than the deque's back (most recent) timestamp, clear or reset the
timestamps deque and the burst_aggregated flag before pushing the new time so
stale "future" entries can't corrupt the 5-second window; update the same logic
in the sibling method that mirrors 99-107 (the other notification
recording/aggregation path) to perform the same check (compare now_secs with
timestamps.back(), clear timestamps and reset burst_aggregated when now_secs <
back) and then proceed to call prune(now_secs) and push_back(now_secs).

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src-tauri/src/adapters/driven/notification/notification_bridge.rs">

<violation number="1" location="src-tauri/src/adapters/driven/notification/notification_bridge.rs:129">
P1: Don't compute transfer duration from `created_at`; it includes queue/paused time and can misreport both duration and average speed in completion notifications. Use an actual transfer-duration metric (or omit these fields until one is available).</violation>

<violation number="2" location="src-tauri/src/adapters/driven/notification/notification_bridge.rs:155">
P2: This truncation exceeds the configured 200-character cap once the ellipsis is appended; reserve one character for `…` before collecting.</violation>
</file>

<file name="src-tauri/src/domain/notification/format.rs">

<violation number="1" location="src-tauri/src/domain/notification/format.rs:23">
P3: Promote to the next unit when rounding crosses `1024.0`; values just below a boundary can currently render as `1024.0 KB/MB` instead of `1.0 MB/GB`.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread src-tauri/src/adapters/driven/notification/notification_bridge.rs Outdated
Comment thread src-tauri/src/adapters/driven/notification/notification_bridge.rs Outdated
Comment thread src-tauri/src/domain/notification/format.rs
- domain/notification/format.rs: promote unit when rounding crosses
  1024.0 so 1 MiB - 1 byte renders as "1.0 MB" instead of "1024.0 KB"
  (cubic P3, coderabbit suggestion). New test
  test_format_size_promotes_unit_when_rounding_crosses_boundary.
- domain/notification/grouper.rs: detect wall-clock backwards jumps
  (NTP correction, manual time change) and clear stale "future"
  timestamps + reset burst flag before pruning so corrupted windows
  cannot bias subsequent decisions (coderabbit suggestion). New test
  test_backwards_clock_jump_resets_window_and_returns_show_single.
- adapters/driven/notification/notification_bridge.rs: drop avg_speed
  and total duration from the completion body. DownloadDetailView only
  exposes created_at (queue admission), so the previous duration
  inflated by queued/paused time and the derived speed reported the
  wrong value (cubic P1, greptile suggestion). Body is now
  "{filename} · {size}"; the omission is documented inline so the
  fields can be reintroduced once the read model surfaces a real
  transfer-start metric (e.g. via the HistoryEntry produced on
  completion).
- truncate_error: reserve one slot for '…' before collecting so the
  rendered failure body respects MAX_ERROR_BODY_CHARS exactly instead
  of overflowing by one (cubic P2, coderabbit suggestion). Replaced
  test_failed_body_truncates_long_error_messages with two focused
  tests covering verbatim short strings and the exact-cap behaviour.
- CHANGELOG.md: rewrite the task 19 entry to match the shipped scope
  (filename + size only, rounding-aware formatter, backwards-clock
  guard) and explain the duration/speed deferral.
Comment thread src-tauri/src/adapters/driven/notification/notification_bridge.rs Outdated
The notifications gate at the top of the EventBus subscriber returned
early for every event, including the tracing::error! call for
DownloadFailed. Disabling desktop notifications therefore silenced the
error log too — a regression flagged by greptile P1 on PR #113.

Move the error log before the gate so observability is independent of
the user's toast preference; the toast itself stays gated.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src-tauri/src/adapters/driven/notification/notification_bridge.rs`:
- Around line 57-59: The match arm handling DomainEvent::DownloadFailed
currently logs the raw error (error = %error) which can leak sensitive payload;
replace that structured field with a sanitized summary instead (e.g.,
error_summary = %sanitize_error_summary(&error) or call
summarize_download_error(&error)) and implement a small helper
(sanitize_error_summary / summarize_download_error) referenced from
notification_bridge.rs that strips URLs/tokens/headers, collapses large bodies,
and limits length before returning a safe string for logging; update the
tracing::error! call in the DomainEvent::DownloadFailed arm to use that helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4a623fd3-9dcf-43e1-8f89-748744af3b82

📥 Commits

Reviewing files that changed from the base of the PR and between 413e2a0 and 1b59251.

📒 Files selected for processing (1)
  • src-tauri/src/adapters/driven/notification/notification_bridge.rs

Comment thread src-tauri/src/adapters/driven/notification/notification_bridge.rs Outdated
The structured `error = %error` field on the failure log can carry
URLs, auth tokens or hoster response bodies into global tracing
output, which gets persisted and aggregated. The per-download log
bridge already captures the full error against the download id, so
the global log only needs the id for correlation. Flagged by
coderabbit on PR #113.
@mpiton mpiton merged commit a0feb17 into main Apr 27, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation rust

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant